spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Bars, HBars } from '@/components/charts/charts';5import { LaunchesTable } from '@/components/launches/launches-table';6import { SitesMap } from '@/components/launches/sites-map';7import { Block, Empty, Head } from '@/components/satellite/primitives';8import { Container, Stat } from '@/components/ui/section';9import { Unavailable } from '@/components/ui/unavailable';10import { api, ApiError, safe } from '@/lib/api';11import { fmtDate, fmtInt, num } from '@/lib/format';12import { routes, SITE_URL } from '@/lib/site';1314type Params = { params: Promise<{ slug: string }> };15type SiteDetail = Awaited<ReturnType<typeof api.launchSite>>['data'];1617async function load(slug: string): Promise<SiteDetail> {18 try {19 return (await api.launchSite(slug)).data;20 } catch (e) {21 if (e instanceof ApiError && e.notFound) notFound();22 throw e;23 }24}2526/** The detail endpoint has no totals; derive them from the per-year series (real data, no guesses). */27function totals(s: SiteDetail) {28 const launches = s.years.reduce((a, y) => a + (num(y.launches) ?? 0), 0);29 const payloads = s.years.reduce((a, y) => a + (num(y.payloads) ?? 0), 0);30 const first = s.years[0]?.year ?? null;31 const last = s.years[s.years.length - 1]?.year ?? null;32 const lastLaunch = s.recent_launches.reduce<string | null>((m, l) => (l.launch_date && (!m || l.launch_date > m) ? l.launch_date : m), null);33 return { launches, payloads, first, last, lastLaunch };34}3536export async function generateMetadata({ params }: Params): Promise<Metadata> {37 const { slug } = await params;38 const res = await safe(api.launchSite(slug));39 if (!res) return { title: 'Launch site', robots: { index: false } };40 const s = res.data;41 const t = totals(s);42 const title = `${s.name} — Launch site${s.country_name ? `, ${s.country_name}` : ''}`;43 const description = `${s.name}${s.country_name ? ` (${s.country_name})` : ''}: ${fmtInt(t.launches)} orbital launches and ${fmtInt(t.payloads)} payloads${t.first ? ` since ${t.first}` : ''}${t.lastLaunch ? `, most recent on ${fmtDate(t.lastLaunch)}` : ''}. Launches per year, recent launches and top owners on SatelliteIndex.`;44 const canonical = routes.launchSite(s.slug);45 return { title, description, alternates: { canonical }, openGraph: { title, description, url: `${SITE_URL}${canonical}` }, twitter: { card: 'summary', title, description } };46}4748export default async function LaunchSitePage({ params }: Params) {49 const { slug } = await params;50 const s = await load(slug);51 const t = totals(s);52 const hasCoords = s.latitude !== null && s.longitude !== null;53 const yearsData = s.years.map((y) => ({ x: String(y.year), y: num(y.launches) ?? 0 }));54 const owners = [...s.owners].sort((a, b) => (num(b.launches) ?? 0) - (num(a.launches) ?? 0)).slice(0, 12);55 const jsonLd = { '@context': 'https://schema.org', '@type': 'Place', name: s.name, url: `${SITE_URL}${routes.launchSite(s.slug)}`, identifier: s.code, ...(hasCoords ? { geo: { '@type': 'GeoCoordinates', latitude: s.latitude, longitude: s.longitude } } : {}), ...(s.country_name ? { address: { '@type': 'PostalAddress', addressCountry: s.country_code ?? s.country_name } } : {}) };5657 return (58 <Container wide>59 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />60 <header className="pb-6 pt-6 md:pt-10">61 <p className="eyebrow mono">62 Launch site · {s.code}63 {hasCoords && <> · {s.latitude!.toFixed(2)}°, {s.longitude!.toFixed(2)}°</>}64 </p>65 <h1 className="display mt-2 break-words text-3xl md:text-5xl">{s.name}</h1>66 <p className="mt-3 text-sm text-ink-2">67 {s.country_slug ? <Link href={routes.country(s.country_slug)} className="link">{s.country_name}</Link> : s.country_name ?? 'Country unknown'}68 {' · '}69 <Link href={routes.launches(`site=${encodeURIComponent(s.slug)}`)} className="link">All launches from this site</Link>70 </p>71 </header>7273 <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-start">74 <div className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-4 lg:grid-cols-2">75 <Stat label="Orbital launches" value={fmtInt(t.launches)} hint={t.first ? `since ${t.first}` : undefined} />76 <Stat label="Payloads" value={fmtInt(t.payloads)} />77 <Stat label="Active years" value={fmtInt(s.years.length)} hint={t.first && t.last ? `${t.first} – ${t.last}` : undefined} />78 <Stat label="Most recent launch" value={<span className="text-xl md:text-2xl">{fmtDate(t.lastLaunch)}</span>} hint={t.lastLaunch ? undefined : 'no dated launch in the recent list'} />79 </div>80 <div>81 {hasCoords ? <SitesMap sites={[{ code: s.code, name: s.name, slug: s.slug, country_code: s.country_code, latitude: s.latitude, longitude: s.longitude, launches: t.launches, launches_last_365d: 0, last_launch: t.lastLaunch }]} highlight={s.slug} labelTop={1} /> : <Unavailable what="Site coordinates" compact />}82 </div>83 </div>8485 <div className="mt-6 divide-y divide-[color:var(--rule)]">86 <Block id="years">87 <Head eyebrow="Cadence" title="Launches per year" />88 {yearsData.length ? <Bars data={yearsData} title={`Launches per year from ${s.name}`} height={200} xTicks={10} highlightLast /> : <Empty>No dated launches on file.</Empty>}89 </Block>9091 <Block id="recent">92 <Head eyebrow="Recent" title={<>Recent launches <span className="tnum text-ink-3">· {fmtInt(s.recent_launches.length)}</span></>} action={{ href: routes.launches(`site=${encodeURIComponent(s.slug)}`), label: 'Full list' }} />93 {s.recent_launches.length ? <LaunchesTable rows={s.recent_launches} showSite={false} ownerHref={(c) => routes.launches(`site=${encodeURIComponent(s.slug)}&owner=${encodeURIComponent(c)}`)} /> : <Empty>No launches recorded for this site.</Empty>}94 </Block>9596 <Block id="owners">97 <Head eyebrow="Customers" title="Top owners launched from here" />98 {owners.length ? (99 <HBars data={owners.map((o) => ({ label: `${o.name} (${o.code})`, value: num(o.launches) ?? 0 }))} max={num(owners[0]?.launches) ?? undefined} />100 ) : (101 <Empty>No owner information for the objects launched from this site.</Empty>102 )}103 {owners.length > 0 && (104 <ul className="mt-3 flex flex-wrap gap-1.5">105 {owners.map((o) => (106 <li key={o.code}>107 <Link href={routes.launches(`site=${encodeURIComponent(s.slug)}&owner=${encodeURIComponent(o.code)}`)} className="mono inline-flex min-h-9 items-center rounded-md border border-rule px-2.5 text-xs text-ink-2 hover:border-rule-strong hover:text-ink">{o.code}</Link>108 </li>109 ))}110 </ul>111 )}112 </Block>113 </div>114115 <p className="pb-10 pt-4 text-2xs text-ink-3">116 Totals are summed from the per-year series of launches attributed to site code {s.code} in SATCAT; launches are derived from international designators (<Link href={routes.methodology()} className="hover:text-accent">methodology</Link>).117 </p>118 </Container>119 );120}121